Repository navigation
Routed per-subset state dict gets into NIXL CUDA destinations - #252
Merged
Merged
Conversation
d4l3k
force-pushed
the
d4l3k/gen-direct-gpu
branch
from
October 28, 2026 22:07
102f92e to
b34e58c
Compare
d4l3k
force-pushed
the
d4l3k/gen-direct-gpu
branch
from
October 28, 2026 23:51
b34e58c to
8e7b41b
Compare
d4l3k
marked this pull request as ready for review
October 29, 2026 00:15
brandondfeng
requested changes
Oct 29, 2026
brandondfeng
approved these changes
Oct 29, 2026
Routed get_state_dict planned only the first call's keys and fetched every planned key, so a reader loading one parameter group at a time got no routes for later groups and allocated the others. Plan new keys on each call and make non-strict routed gets fetch only the passed keys. NIXL cached CUDA get destinations by storage, but FSDP frees and reallocates parameter storage in place, leaving the cached registration over freed memory. Register those destinations per get and deregister in drop().
…olume writes; dedupe registrations A cancelled get now waits (up to TORCHSTORE_NIXL_TIMEOUT_S) for the volume's answer before drop() deregisters its CUDA destinations; on a timeout or a second cancel it returns and releases them when the volume answers. acquire() replaces a cached registration of the same storage and register() reuses a live lease: two registrations of one range make peers fail to load the agent (NIXL_ERR_NOT_ALLOWED), e.g. a CUDA put source later used as a get destination. drop() releases every lease even if one fails. Non-strict routed gets no longer mark unpublished keys as planned.
d4l3k
force-pushed
the
d4l3k/gen-direct-gpu
branch
from
October 29, 2026 22:24
2980a95 to
144b7e1
Compare
d4l3k
added a commit
to pytorch/torchtitan
that referenced
this pull request
Nov 3, 2026
Root issue: RL generators prefetch each policy from the StorageVolume into a full pinned CPU copy of the weights before loading it into vLLM. GB300 generator hosts have little CPU memory and OOM on this copy. Fix: one load path, `VLLMModelWrapper.load_model_state_dict(fetch)`, walks the FSDP groups one at a time (reshard, restore sharded params, `fetch` the group's subset in place, unshard and free; buffers last). Every parameter must be filled. `Generator.Config.model_state_dict_load_mode` picks the fetch: | `model_state_dict_load_mode` | prefetch | fetch during load | generator host memory | |---|---|---|---| | `"cpu_prefetch"` (default, previous behavior) | StorageVolume -> pinned CPU copy, overlaps generation | `copy_` from the pinned copy | +1 full weight copy | | `"gpu_fetch"` | no-op | TorchStore read straight into the GPU weights | none | A gloo all-reduce agrees on load failure across generator ranks before rank 0 replies; on a fetch failure every rank still unshards every group (avoids an all-gather hang) and then stops the engine loop. gpu_fetch tradeoff: the transfer runs inside the load while the engine is paused rather than overlapping generation. gpu_fetch rejects quantized parameters at init. Parameters whose dtype differs from the model dtype (float32 GDN `A_log`/`dt_bias`, #5691) are fetched into model-dtype staging tensors and cast, since the trainer sends the model dtype and torchstore requires matching dtypes. The trainer-side duplicate copy (#5654) was removed by #5659. Requires meta-pytorch/torchstore#252 (merged). Test plan: - CPU: `pytest tests/rl/unit_tests/cpu` with no GPU: all pass (gpu_fetch load path covered by `test_vllm_wrapper`/`test_generator`, incl. float32 param staging). - GPU (GB200): `torchrun --nproc-per-node=2 -m pytest tests/rl/unit_tests/gpu/test_bitwise_parity.py::TestBitwiseParityQwen35DebugVarlen`: 5 passed on both ranks. Trainer vs vLLM prefill and decode are bitwise identical, including a reload through `load_model_state_dict` after CUDA graph replay. - E2E async RL on GB200 through TorchStore (before merging main): - Qwen3 debugmodel, gen TP2, 4 steps: logprob_diff/max 0.0099-0.012 in both modes. - Qwen3 8B, gen TP1, 5 syncs: logprob_diff/max 0.21-0.31 in both modes (same as before this PR; untrained weights, not batch invariant). Generator memory, Qwen3 8B, gen TP1: | mode | host RSS | GPU growth in sync | sync time | |---|---|---|---| | `cpu_prefetch` | 24.7 GiB (16 GiB pinned copy) | +1 GiB on first sync only | 0.48 s, overlapped with generation | | `gpu_fetch` | 3.8-3.9 GiB | 0 | 1.1-1.2 s, engine paused | Lines changed: impl +306/-88, tests +436/-100, CI 0.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Root issue: torchtitan RL generators prefetch each policy into a full pinned CPU copy of the weights, and this copy OOMs GB300 generator hosts, which have little CPU memory. The companion torchtitan PR reads the StorageVolume snapshot straight into the generator's GPU parameters, one FSDP group at a time. Two pieces were missing in torchstore:
RoutingClient.get_layoutsplanned only the first state dict it saw, so a laterget_state_dictfor another subset returned the first subset's keys in fresh tensors and none of the requested ones.resize_, so a cached registration ends up over freed memory and reads stale bytes. With expandable segments, UCX also registers only the 20 MiB cuMem chunk holding a buffer's start. A buffer that fits within one chunk but crosses a chunk boundary then fails registration (ibv_reg_dmabuf_mr: Invalid argumenton GB200) or, past the registered part, RDMA with a local access violation that hangs the transfer. UCX's registration cache also keeps deregistered memory registered, so CUDA memory torch frees after a get stays pinned and the device cannot reuse it (measured: freed destinations missing fromcudaMemGetInfountil the process exits).Fix:
RoutingClient.get_layoutsplans every key the first time it is passed, against only that subset's publisher entries, and fetches publisher layouts once per state dict.get_state_dict(strict=False)fetches only the passed subset; keys it skips as unpublished stay unplanned, so a later strict get still rejects them.NIXL_ERR_NOT_ALLOWED).TORCHSTORE_NIXL_TIMEOUT_S, for the storage volume to answer before deregistering its destinations, since the volume may still be writing into them. After a timeout or a second cancel, the destinations stay registered and referenced until the volume answers, and getting into the same storage meanwhile raises. This uses a newTransportBuffer._call_volume_gethook.engine_configCUDA_COPY_REG_WHOLE_ALLOC=off,RCACHE_ENABLE=n, scoped to torchstore's agent: every registration covers its whole range, and deregistering releases the memory. ExportedUCX_CUDA_COPY_REG_WHOLE_ALLOC/UCX_RCACHE_ENABLEstill override these.BC: no API change. Routed non-strict gets return the passed keys rather than those of the first get. Routed strict gets still require the passed dict to match every key planned so far.
Companion torchtitan PR (lands after this one): pytorch/torchtitan#5652. Duplicate trainer copy follow-up: pytorch/torchtitan#5654
Test plan:
pytest tests/routing tests/test_nixl_transport.py tests/test_nixl_transport_cuda.py tests/test_state_dict.pyplustests/test_state_dict_utils.py, rebased on main, on GB200 with nixl 1.5.0: 135 passed, 1 failed. The failure,test_state_dict_transfer_dtype[MonarchRDMA-cuda](RDMA completion error status=12 in the put path), also fails 5/5 on main. Cancellation tests cover waiting for the volume's answer, release after a timeout or second cancel, and refusing to reuse a storage the volume may still write into. The CUDA tests use real NIXL and cover a destination spanning cuMem chunks with expandable segments (fails registration withoutCUDA_COPY_REG_WHOLE_ALLOC=off) and a freed destination returning to the device (fails withoutRCACHE_ENABLE=n).copy_baseline, does not grow over 20 gets, and freed destinations return to the device. NIXL segfaults at interpreter exit on this host for any process that creates an agent, so judge by the pytest summary.fetch_model_state_dict_to_gpu(Qwen3.5, GB200): rc=0, logprob_diff 0.010-0.014, matching the staged path.Limitations:
(data_ptr, nbytes)cannot detect FSDP reallocation at the same address.tests/test_nixl_transport_cuda.pyskips in CI (nixl is not installed); it was run manually.RCACHE_ENABLE=napplies to all memory on both agents, so uncached registrations (per-get CPU destinations, staging buffers, non-contiguous copies) pay a full register/deregister each time. Long-lived registrations are cached by torchstore.resize_from freeing that memory, and gets refuse the storage only if it comes back at the same address and size.Mapping is missing); later gets now recover.Lines changed: impl +218/-14, tests +460/-2.